D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\cli.rs
Line | Count | Source |
1 | | //! CLI interface |
2 | | |
3 | | use crate::client::main as client_main; |
4 | | use crate::daemon::{main as daemon_main, resolve_cluster_tags}; |
5 | | use crate::utils::config::{ClientConfig, Cluster, Config, ConfigOpt, DaemonConfig}; |
6 | | use crate::utils::windows::WindowsApi; |
7 | | use crate::{ |
8 | | current_exe_path, get_console_window_handle, init_logger, is_launched_from_gui, |
9 | | spawn_console_process, WindowsSettingsDefaultTerminalApplicationGuard, |
10 | | }; |
11 | | use clap::{ArgAction, CommandFactory, Parser, Subcommand}; |
12 | | |
13 | | #[cfg(test)] |
14 | | use mockall::{automock, predicate::*}; |
15 | | use windows::Win32::UI::HiDpi::PROCESS_PER_MONITOR_DPI_AWARE; |
16 | | |
17 | | use cssh_rs_meta::PACKAGE_NAME; |
18 | | |
19 | | /// Cross-platform cluster SSH tool |
20 | | /// |
21 | | /// The main CLI arguments |
22 | | #[derive(Parser, Debug)] |
23 | | #[clap(author, version, about, long_about = None)] |
24 | | pub struct Args { |
25 | | /// Optional subcommand |
26 | | /// Usually not specified by the user |
27 | | #[clap(subcommand)] |
28 | | command: Option<Commands>, |
29 | | /// Optional username used to connect to the hosts |
30 | | #[clap(long, short = 'u')] |
31 | | username: Option<String>, |
32 | | /// Optional port used for all SSH connections |
33 | | #[clap(long, short = 'p')] |
34 | | port: Option<u16>, |
35 | | /// Hosts and/or cluster tag(s) to connect to |
36 | | /// |
37 | | /// Hosts or cluster tags might use brace expansion, |
38 | | /// but need to be properly quoted. |
39 | | /// |
40 | | /// E.g.: `cssh-rs.exe "host{1..3}" hostA` |
41 | | /// |
42 | | /// Hosts can include a username which will take precedence over the |
43 | | /// username given via the `-u` option and over any ssh config value. |
44 | | /// |
45 | | /// E.g.: `cssh-rs.exe -u user3 user1@host1 userA@hostA host3` |
46 | | /// |
47 | | /// Hosts can include a port number which will take precedence over the |
48 | | /// port given via the `-p` option. |
49 | | /// |
50 | | /// E.g.: `cssh-rs.exe -p 33 host1:11 host2:22 host3` |
51 | | /// |
52 | | /// If no hosts are provided and the application is launched in a new console window |
53 | | /// (e.g. by double clicking the executable in the File Explorer), |
54 | | /// it will launch in interactive mode. |
55 | | #[clap(required = false, global = true)] |
56 | | hosts: Vec<String>, |
57 | | /// Enable extensive logging |
58 | | #[clap(short, long, action=ArgAction::SetTrue)] |
59 | | debug: bool, |
60 | | } |
61 | | |
62 | | /// The ``command`` CLI subcommand |
63 | | #[derive(Debug, Subcommand, PartialEq)] |
64 | | enum Commands { |
65 | | /// Subcommand that will launch a single client window |
66 | | /// |
67 | | /// connecting to the given host with the given username. |
68 | | /// It will also try to read input from a daemon via the named pipe. |
69 | | Client { |
70 | | /// Host to connect to |
71 | | host: String, |
72 | | }, |
73 | | /// Subcommand that will launch the daemon window. |
74 | | /// |
75 | | /// The daemon is responsible to launch the client windows, |
76 | | /// one for each given host. |
77 | | /// For each client a named pipe will be created and any keystrokes |
78 | | /// the daemon window receives are forwarded via the pipes to all the clients. |
79 | | /// Also handles control mode. |
80 | | Daemon {}, |
81 | | /// Write a default config file at the given output path. |
82 | | /// |
83 | | /// Values that differ from the defaults can be set via the options. |
84 | | GenerateConfig { |
85 | | /// Path to an SSH config file. When set, the launched program |
86 | | /// receives `-F <PATH>` as the first two argv entries. |
87 | | #[clap(long)] |
88 | | ssh_config_path: Option<String>, |
89 | | /// Program launched to establish each SSH connection. |
90 | | /// Defaults to the `client.program` config default when unset. |
91 | | #[clap(long)] |
92 | | program: Option<String>, |
93 | | /// Program arguments written to `client.arguments`, replacing the |
94 | | /// default bare `<user>@<host>` placeholder; repeatable and must |
95 | | /// include it. Use the `--arguments=VALUE` form for values starting |
96 | | /// with `-`, e.g. `--arguments=-XY --arguments={{USERNAME_AT_HOST}}`. |
97 | | #[clap(long)] |
98 | | arguments: Vec<String>, |
99 | | /// Name of the single cluster written to the config. |
100 | | #[clap(long, default_value = "default")] |
101 | | cluster: String, |
102 | | /// Path of the config file to write. Defaults to |
103 | | /// `cssh-rs-config.toml` next to the running executable. |
104 | | #[clap(long)] |
105 | | output: Option<String>, |
106 | | }, |
107 | | } |
108 | | |
109 | | /// Main Entrypoint struct |
110 | | /// |
111 | | /// Used to implement the entrypoint functions of the different |
112 | | /// subcommands |
113 | | pub struct MainEntrypoint; |
114 | | |
115 | | /// Trait for Args operations to enable mocking in tests |
116 | | #[cfg_attr(test, automock)] |
117 | | pub trait ArgsCommand { |
118 | | /// Print help message |
119 | | fn print_help(&self) -> Result<(), std::io::Error>; |
120 | | } |
121 | | |
122 | | /// Default implementation of ArgsCommand trait |
123 | | pub struct CLIArgsCommand; |
124 | | |
125 | | impl ArgsCommand for CLIArgsCommand { |
126 | 0 | fn print_help(&self) -> Result<(), std::io::Error> { |
127 | 0 | return Args::command().print_help(); |
128 | 0 | } |
129 | | } |
130 | | |
131 | | /// Trait for logger initialization to enable mocking in tests |
132 | | #[cfg_attr(test, automock)] |
133 | | pub trait LoggerInitializer { |
134 | | /// Initialize logger with the given name |
135 | | fn init_logger(&self, name: &str); |
136 | | } |
137 | | |
138 | | /// Default implementation of LoggerInitializer trait |
139 | | pub struct CLILoggerInitializer; |
140 | | |
141 | | impl LoggerInitializer for CLILoggerInitializer { |
142 | 0 | fn init_logger(&self, name: &str) { |
143 | 0 | init_logger(name); |
144 | 0 | } |
145 | | } |
146 | | |
147 | | /// Trait for writing output to enable dependency injection and testing |
148 | | #[cfg_attr(test, automock)] |
149 | | pub trait Output { |
150 | | /// Write a line to the output |
151 | | fn println(&mut self, text: &str); |
152 | | /// Write text without a newline to the output |
153 | | fn print(&mut self, text: &str); |
154 | | /// Write a line to stderr |
155 | | fn eprintln(&mut self, text: &str); |
156 | | /// Flush the output |
157 | | fn flush(&mut self); |
158 | | } |
159 | | |
160 | | /// Default implementation of Output trait that writes to stdout/stderr |
161 | | pub struct CLIOutput; |
162 | | |
163 | | impl Output for CLIOutput { |
164 | 0 | fn println(&mut self, text: &str) { |
165 | 0 | println!("{text}"); |
166 | 0 | } |
167 | | |
168 | 0 | fn print(&mut self, text: &str) { |
169 | 0 | print!("{text}"); |
170 | 0 | } |
171 | | |
172 | 0 | fn eprintln(&mut self, text: &str) { |
173 | 0 | eprintln!("{text}"); |
174 | 0 | } |
175 | | |
176 | 0 | fn flush(&mut self) { |
177 | | use std::io::Write; |
178 | 0 | std::io::stdout().flush().unwrap(); |
179 | 0 | } |
180 | | } |
181 | | |
182 | | /// Trait for reading input to enable dependency injection and testing |
183 | | #[cfg_attr(test, automock)] |
184 | | pub trait Input { |
185 | | /// Read a line from stdin |
186 | | fn read_line(&mut self) -> Result<String, std::io::Error>; |
187 | | } |
188 | | |
189 | | /// Default implementation of Input trait that reads from stdin |
190 | | pub struct CLIInput; |
191 | | |
192 | | impl Input for CLIInput { |
193 | 0 | fn read_line(&mut self) -> Result<String, std::io::Error> { |
194 | 0 | let mut input = String::new(); |
195 | 0 | std::io::stdin().read_line(&mut input)?; |
196 | 0 | return Ok(input); |
197 | 0 | } |
198 | | } |
199 | | |
200 | | /// Trait for environment operations to enable dependency injection and testing |
201 | | #[cfg_attr(test, automock)] |
202 | | pub trait Environment { |
203 | | /// Get current executable path |
204 | | fn current_exe(&self) -> Result<std::path::PathBuf, std::io::Error>; |
205 | | /// Set current directory |
206 | | fn set_current_dir(&self, path: &std::path::Path) -> Result<(), std::io::Error>; |
207 | | } |
208 | | |
209 | | /// Default implementation of Environment trait |
210 | | pub struct CLIEnvironment; |
211 | | |
212 | | impl Environment for CLIEnvironment { |
213 | 0 | fn current_exe(&self) -> Result<std::path::PathBuf, std::io::Error> { |
214 | 0 | return std::env::current_exe(); |
215 | 0 | } |
216 | | |
217 | 0 | fn set_current_dir(&self, path: &std::path::Path) -> Result<(), std::io::Error> { |
218 | 0 | return std::env::set_current_dir(path); |
219 | 0 | } |
220 | | } |
221 | | |
222 | | /// Trait for configuration management to enable dependency injection and testing |
223 | | #[cfg_attr(test, automock)] |
224 | | pub trait ConfigManager { |
225 | | /// Load configuration from the specified path |
226 | | fn load_config(&self, path: &str) -> Result<ConfigOpt, confy::ConfyError>; |
227 | | /// Store configuration to the specified path |
228 | | fn store_config(&self, path: &str, config: &Config) -> Result<(), confy::ConfyError>; |
229 | | } |
230 | | |
231 | | /// Default implementation of ConfigManager trait |
232 | | pub struct CLIConfigManager; |
233 | | |
234 | | impl ConfigManager for CLIConfigManager { |
235 | 0 | fn load_config(&self, path: &str) -> Result<ConfigOpt, confy::ConfyError> { |
236 | 0 | return confy::load_path(path); |
237 | 0 | } |
238 | | |
239 | 0 | fn store_config(&self, path: &str, config: &Config) -> Result<(), confy::ConfyError> { |
240 | 0 | return confy::store_path(path, config); |
241 | 0 | } |
242 | | } |
243 | | |
244 | | /// Trait defining the entrypoint functions of the different |
245 | | /// subcommands |
246 | | #[cfg_attr(test, automock)] |
247 | | pub trait Entrypoint { |
248 | | /// Entrypoint for the client subcommand |
249 | | fn client_main<W: WindowsApi + 'static>( |
250 | | &mut self, |
251 | | windows_api: &W, |
252 | | host: String, |
253 | | username: Option<String>, |
254 | | port: Option<u16>, |
255 | | config: &ClientConfig, |
256 | | ) -> impl std::future::Future<Output = ()> + Send; |
257 | | /// Entrypoint for the daemon subcommand |
258 | | fn daemon_main<W: WindowsApi + Clone + 'static>( |
259 | | &mut self, |
260 | | windows_api: &W, |
261 | | hosts: Vec<String>, |
262 | | username: Option<String>, |
263 | | port: Option<u16>, |
264 | | config: &DaemonConfig, |
265 | | clusters: &[Cluster], |
266 | | debug: bool, |
267 | | ) -> impl std::future::Future<Output = ()> + Send; |
268 | | /// Entrypoint for the main command |
269 | | fn main<W: WindowsApi + 'static, C: ConfigManager + 'static>( |
270 | | &mut self, |
271 | | windows_api: &W, |
272 | | config_manager: &C, |
273 | | config_path: &str, |
274 | | config: &Config, |
275 | | args: Args, |
276 | | ); |
277 | | } |
278 | | |
279 | | impl Entrypoint for MainEntrypoint { |
280 | 0 | async fn client_main<W: WindowsApi>( |
281 | 0 | &mut self, |
282 | 0 | windows_api: &W, |
283 | 0 | host: String, |
284 | 0 | username: Option<String>, |
285 | 0 | port: Option<u16>, |
286 | 0 | config: &ClientConfig, |
287 | 0 | ) { |
288 | 0 | client_main(windows_api, host, username, port, config).await; |
289 | 0 | } |
290 | | |
291 | 0 | async fn daemon_main<W: WindowsApi + Clone + 'static>( |
292 | 0 | &mut self, |
293 | 0 | windows_api: &W, |
294 | 0 | hosts: Vec<String>, |
295 | 0 | username: Option<String>, |
296 | 0 | port: Option<u16>, |
297 | 0 | config: &DaemonConfig, |
298 | 0 | clusters: &[Cluster], |
299 | 0 | debug: bool, |
300 | 0 | ) { |
301 | 0 | daemon_main(windows_api, hosts, username, port, config, clusters, debug).await; |
302 | 0 | } |
303 | | |
304 | 7 | fn main<W: WindowsApi + 'static, C: ConfigManager + 'static>( |
305 | 7 | &mut self, |
306 | 7 | windows_api: &W, |
307 | 7 | config_manager: &C, |
308 | 7 | config_path: &str, |
309 | 7 | config: &Config, |
310 | 7 | args: Args, |
311 | 7 | ) { |
312 | 7 | config_manager.store_config(config_path, config).unwrap(); |
313 | | |
314 | 7 | let mut daemon_args: Vec<String> = Vec::new(); |
315 | 7 | if args.debug { |
316 | 2 | daemon_args.push("-d".to_string()); |
317 | 5 | } |
318 | 7 | if let Some(username3 ) = args.username { |
319 | 3 | daemon_args.push("-u".to_string()); |
320 | 3 | daemon_args.push(username); |
321 | 4 | } |
322 | 7 | if let Some(port3 ) = args.port { |
323 | 3 | daemon_args.push("-p".to_string()); |
324 | 3 | daemon_args.push(port.to_string()); |
325 | 4 | } |
326 | 7 | daemon_args.push("daemon".to_string()); |
327 | | // Order is important here. If the hosts are passed before the daemon subcommand |
328 | | // it will not be recognizes as such and just be passed along as one of the hosts. |
329 | 7 | daemon_args.extend( |
330 | 7 | resolve_cluster_tags( |
331 | 9 | args.hosts.iter()7 .map7 (|host| return &**host).collect7 (), |
332 | 7 | &config.clusters, |
333 | | ) |
334 | 7 | .into_iter() |
335 | 9 | .map7 (|host| return host.to_string()), |
336 | | ); |
337 | 7 | let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new(); |
338 | | // We must wait for the window to actually launch before dropping the _guard as we might otherwise |
339 | | // reset the configuration before the window was launched |
340 | 7 | let _ = get_console_window_handle( |
341 | 7 | windows_api, |
342 | 7 | spawn_console_process(windows_api, ¤t_exe_path(), daemon_args, true) |
343 | 7 | .expect("Failed to create process") |
344 | 7 | .dwProcessId, |
345 | 7 | ); |
346 | 7 | } |
347 | | } |
348 | | |
349 | | /// Display the interactive mode prompt and instructions |
350 | 12 | fn show_interactive_prompt<O: Output>(output: &mut O) { |
351 | 12 | output.println("\n=== Interactive Mode ==="); |
352 | 12 | output.println(&format!( |
353 | 12 | "Enter your {PACKAGE_NAME} arguments (or press Enter to exit):" |
354 | 12 | )); |
355 | 12 | output.println("Example: -u myuser host1 host2 host3"); |
356 | 12 | output.println("Example: --help"); |
357 | 12 | output.print("> "); |
358 | 12 | output.flush(); |
359 | 12 | } |
360 | | |
361 | | /// Read user input from stdin |
362 | | /// |
363 | | /// # Arguments |
364 | | /// |
365 | | /// * `input` - The Input trait object for reading from stdin |
366 | | /// |
367 | | /// # Returns |
368 | | /// |
369 | | /// * `Ok(Some(input))` - User provided input |
370 | | /// * `Ok(None)` - User wants to exit (empty input or "exit") |
371 | | /// * `Err(error)` - Error reading input |
372 | 16 | fn read_user_input<I: Input>(input: &mut I) -> Result<Option<String>, std::io::Error> { |
373 | 16 | let input_line14 = input.read_line()?2 ; |
374 | | |
375 | 14 | let input_trimmed = input_line.trim(); |
376 | 14 | if input_trimmed.is_empty() || input_trimmed.to_lowercase() == "exit"7 { |
377 | 9 | return Ok(None); |
378 | 5 | } |
379 | | |
380 | 5 | return Ok(Some(input_trimmed.to_string())); |
381 | 16 | } |
382 | | |
383 | | /// Handle special commands that don't need full parsing |
384 | | /// |
385 | | /// # Arguments |
386 | | /// |
387 | | /// * `input` - The user input string |
388 | | /// * `args_command` - The ArgsCommand trait object for printing help |
389 | | /// |
390 | | /// # Returns |
391 | | /// |
392 | | /// * `true` - Command was handled, continue loop |
393 | | /// * `false` - Command needs full parsing |
394 | 13 | fn handle_special_commands<A: ArgsCommand>(input: &str, args_command: &A) -> bool { |
395 | 13 | if input == "--help" || input == "-h"11 { |
396 | 3 | let _ = args_command.print_help(); |
397 | 3 | return true; |
398 | 10 | } |
399 | 10 | return false; |
400 | 13 | } |
401 | | |
402 | | /// Execute the interactively entered command, rejecting any subcommand. |
403 | | /// |
404 | | /// Interactive mode accepts only cssh options and positional arguments. |
405 | 6 | async fn execute_parsed_command< |
406 | 6 | W: WindowsApi + Clone + 'static, |
407 | 6 | T: Entrypoint, |
408 | 6 | A: ArgsCommand, |
409 | 6 | O: Output, |
410 | 6 | C: ConfigManager + 'static, |
411 | 6 | >( |
412 | 6 | windows_api: &W, |
413 | 6 | parsed_args: Args, |
414 | 6 | entrypoint: &mut T, |
415 | 6 | args_command: &A, |
416 | 6 | output: &mut O, |
417 | 6 | config_manager: &C, |
418 | 6 | config: &Config, |
419 | 6 | config_path: &str, |
420 | 6 | ) { |
421 | 6 | match &parsed_args.command { |
422 | 3 | Some(_) => { |
423 | 3 | output.eprintln( |
424 | 3 | "Subcommands are not supported in interactive mode. Use cssh options and positional arguments only.", |
425 | 3 | ); |
426 | 3 | } |
427 | | None => { |
428 | 3 | if !parsed_args.hosts.is_empty() { |
429 | 2 | entrypoint.main( |
430 | 2 | windows_api, |
431 | 2 | config_manager, |
432 | 2 | config_path, |
433 | 2 | config, |
434 | 2 | parsed_args, |
435 | 2 | ); |
436 | 2 | } else { |
437 | 1 | // Show help for empty hosts |
438 | 1 | let _ = args_command.print_help(); |
439 | 1 | } |
440 | | } |
441 | | } |
442 | 6 | } |
443 | | |
444 | | /// Run the interactive mode loop for GUI launches |
445 | 6 | async fn run_interactive_mode< |
446 | 6 | W: WindowsApi + Clone + 'static, |
447 | 6 | A: ArgsCommand, |
448 | 6 | T: Entrypoint, |
449 | 6 | O: Output, |
450 | 6 | I: Input, |
451 | 6 | C: ConfigManager + 'static, |
452 | 6 | >( |
453 | 6 | windows_api: &W, |
454 | 6 | args_command: &A, |
455 | 6 | mut entrypoint: T, |
456 | 6 | config_manager: &C, |
457 | 6 | config: &Config, |
458 | 6 | config_path: &str, |
459 | 6 | output: &mut O, |
460 | 6 | input: &mut I, |
461 | 6 | ) { |
462 | | loop { |
463 | 11 | show_interactive_prompt(output); |
464 | | |
465 | 11 | match read_user_input(input) { |
466 | 4 | Ok(Some(input_str)) => { |
467 | | // Handle special commands first |
468 | 4 | if handle_special_commands(&input_str, args_command) { |
469 | 1 | continue; |
470 | 3 | } |
471 | | |
472 | | // Parse the input as command line arguments |
473 | 3 | let input_args: Vec<&str> = input_str.split_whitespace().collect(); |
474 | 3 | let mut full_args = vec![PACKAGE_NAME]; |
475 | 3 | full_args.extend(input_args); |
476 | | |
477 | 3 | match Args::try_parse_from(full_args) { |
478 | 2 | Ok(parsed_args) => { |
479 | 2 | execute_parsed_command( |
480 | 2 | windows_api, |
481 | 2 | parsed_args, |
482 | 2 | &mut entrypoint, |
483 | 2 | args_command, |
484 | 2 | output, |
485 | 2 | config_manager, |
486 | 2 | config, |
487 | 2 | config_path, |
488 | 2 | ) |
489 | 2 | .await; |
490 | | } |
491 | 1 | Err(err) => { |
492 | 1 | output.eprintln(&format!("\nError parsing arguments: {err}")); |
493 | 1 | } |
494 | | } |
495 | | } |
496 | | Ok(None) => { |
497 | 6 | return; |
498 | | } |
499 | 1 | Err(err) => { |
500 | 1 | output.eprintln(&format!("Error reading input: {err}")); |
501 | 1 | } |
502 | | } |
503 | | } |
504 | 6 | } |
505 | | |
506 | | /// Build the config emitted by `generate-config`. A set `ssh_config_path` both |
507 | | /// prepends `-F <path>` to `client.arguments` and sets `client.ssh_config_path`; |
508 | | /// non-empty `arguments` replace the default `<user>@<host>` placeholder. |
509 | 6 | fn build_generate_config( |
510 | 6 | hosts: Vec<String>, |
511 | 6 | cluster: &str, |
512 | 6 | program: Option<&str>, |
513 | 6 | ssh_config_path: Option<&str>, |
514 | 6 | arguments: Vec<String>, |
515 | 6 | ) -> Config { |
516 | 6 | let mut config = Config { |
517 | 6 | clusters: vec![Cluster { |
518 | 6 | name: cluster.to_string(), |
519 | 6 | hosts, |
520 | 6 | }], |
521 | 6 | ..Config::default() |
522 | 6 | }; |
523 | | |
524 | 6 | if let Some(program5 ) = program { |
525 | 5 | config.client.program = program.to_string(); |
526 | 5 | }1 |
527 | | |
528 | 6 | let mut client_arguments = Vec::new(); |
529 | 6 | if let Some(path3 ) = ssh_config_path { |
530 | 3 | client_arguments.push("-F".to_string()); |
531 | 3 | client_arguments.push(path.to_string()); |
532 | 3 | config.client.ssh_config_path = path.to_string(); |
533 | 3 | } |
534 | 6 | if arguments.is_empty() { |
535 | 5 | client_arguments.push(config.client.username_host_placeholder.clone()); |
536 | 5 | } else { |
537 | 1 | client_arguments.extend(arguments); |
538 | 1 | } |
539 | 6 | config.client.arguments = client_arguments; |
540 | | |
541 | 6 | return config; |
542 | 6 | } |
543 | | |
544 | | /// Write the generated config to `output_path` (or `default_config_path`) and |
545 | | /// print its absolute path to `output`; error if `hosts` is empty or the write |
546 | | /// fails. |
547 | 3 | fn run_generate_config<O: Output, C: ConfigManager>( |
548 | 3 | output: &mut O, |
549 | 3 | config_manager: &C, |
550 | 3 | default_config_path: &str, |
551 | 3 | hosts: Vec<String>, |
552 | 3 | cluster: &str, |
553 | 3 | program: Option<&str>, |
554 | 3 | ssh_config_path: Option<&str>, |
555 | 3 | arguments: Vec<String>, |
556 | 3 | output_path: Option<&str>, |
557 | 3 | ) -> Result<(), String> { |
558 | 3 | if hosts.is_empty() { |
559 | 1 | return Err("generate-config requires at least one host".to_string()); |
560 | 2 | } |
561 | | |
562 | 2 | let config = build_generate_config(hosts, cluster, program, ssh_config_path, arguments); |
563 | 2 | let target_path = std::path::PathBuf::from(output_path.unwrap_or(default_config_path)); |
564 | 2 | let target_str = target_path.to_string_lossy().into_owned(); |
565 | | |
566 | 2 | config_manager |
567 | 2 | .store_config(&target_str, &config) |
568 | 2 | .map_err(|err| return format!0 ("Failed to write config to {target_str}: {err}"))?0 ; |
569 | | |
570 | | // canonicalize can fail on some filesystems; fall back to a lexical absolute path. |
571 | 2 | let resolved = std::fs::canonicalize(&target_path) |
572 | 2 | .or_else(|_| return std::path::absolute(&target_path)) |
573 | 2 | .map(|p| return p.to_string_lossy().into_owned()) |
574 | 2 | .unwrap_or(target_str); |
575 | 2 | output.println(&resolved); |
576 | 2 | return Ok(()); |
577 | 3 | } |
578 | | |
579 | | /// The main entrypoint |
580 | | /// |
581 | | /// Parses the CLI arguments, |
582 | | /// loads an existing config or writes the default config to disk, and |
583 | | /// calls the respective subcommand. |
584 | | /// If no subcommand is given we launch the daemon subcommand in a new window. |
585 | 10 | pub async fn main< |
586 | 10 | W: WindowsApi + Clone + 'static, |
587 | 10 | E: Entrypoint, |
588 | 10 | O: Output, |
589 | 10 | I: Input, |
590 | 10 | Env: Environment, |
591 | 10 | A: ArgsCommand, |
592 | 10 | L: LoggerInitializer, |
593 | 10 | C: ConfigManager + 'static, |
594 | 10 | >( |
595 | 10 | windows_api: &W, |
596 | 10 | args: Args, |
597 | 10 | mut entrypoint: E, |
598 | 10 | output: &mut O, |
599 | 10 | input: &mut I, |
600 | 10 | environment: &Env, |
601 | 10 | args_command: &A, |
602 | 10 | logger_initializer: &L, |
603 | 10 | config_manager: &C, |
604 | 10 | ) { |
605 | | // CRITICAL: Check GUI launch BEFORE any output to console |
606 | 10 | let launched_from_gui = is_launched_from_gui(windows_api); |
607 | | |
608 | | // Set DPI awareness programatically. Using the manifest is the recommended way |
609 | | // but conhost.exe does not do any manifest loading. |
610 | | // https://github.com/microsoft/terminal/issues/18464#issuecomment-2623392013 |
611 | 10 | if let Err(err4 ) = windows_api.set_process_dpi_awareness(PROCESS_PER_MONITOR_DPI_AWARE) { |
612 | 4 | output.eprintln(&format!( |
613 | 4 | "Failed to set DPI awareness programatically: {err:?}" |
614 | 4 | )); |
615 | 6 | } |
616 | 10 | match environment.current_exe() { |
617 | 9 | Ok(path) => match path.parent() { |
618 | 1 | None => { |
619 | 1 | output.eprintln("Failed to get executable path parent working directory"); |
620 | 1 | } |
621 | 8 | Some(exe_dir) => { |
622 | 8 | environment |
623 | 8 | .set_current_dir(exe_dir) |
624 | 8 | .expect("Failed to change current working directory"); |
625 | 8 | } |
626 | | }, |
627 | 1 | Err(_) => { |
628 | 1 | output.eprintln("Failed to get executable directory"); |
629 | 1 | } |
630 | | } |
631 | | |
632 | 10 | let config_path = format!("{PACKAGE_NAME}-config.toml"); |
633 | | |
634 | | // Dispatch before load_config so a broken on-disk config cannot break generation. |
635 | | if let Some(Commands::GenerateConfig { |
636 | 0 | ssh_config_path, |
637 | 0 | program, |
638 | 0 | arguments, |
639 | 0 | cluster, |
640 | 0 | output: output_path, |
641 | 4 | }) = &args.command |
642 | | { |
643 | 0 | if let Err(err) = run_generate_config( |
644 | 0 | output, |
645 | 0 | config_manager, |
646 | 0 | &config_path, |
647 | 0 | args.hosts.to_owned(), |
648 | 0 | cluster, |
649 | 0 | program.as_deref(), |
650 | 0 | ssh_config_path.as_deref(), |
651 | 0 | arguments.to_owned(), |
652 | 0 | output_path.as_deref(), |
653 | 0 | ) { |
654 | 0 | output.eprintln(&err); |
655 | 0 | std::process::exit(1); |
656 | 0 | } |
657 | 0 | return; |
658 | 10 | } |
659 | | |
660 | 10 | let config_on_disk: ConfigOpt = config_manager.load_config(&config_path).unwrap(); |
661 | 10 | let config: Config = config_on_disk.into(); |
662 | | |
663 | 4 | match &args.command { |
664 | 0 | Some(Commands::GenerateConfig { .. }) => unreachable!("handled before config load"), |
665 | 2 | Some(Commands::Client { host }) => { |
666 | 2 | if args.debug { |
667 | 1 | logger_initializer.init_logger(&format!("cssh-rs_client_{host}")); |
668 | 1 | } |
669 | 2 | entrypoint |
670 | 2 | .client_main( |
671 | 2 | windows_api, |
672 | 2 | host.to_owned(), |
673 | 2 | args.username.to_owned(), |
674 | 2 | args.port, |
675 | 2 | &config.client, |
676 | 2 | ) |
677 | 2 | .await; |
678 | | } |
679 | | Some(Commands::Daemon {}) => { |
680 | 2 | if args.debug { |
681 | 1 | logger_initializer.init_logger("cssh-rs_daemon"); |
682 | 1 | } |
683 | 2 | entrypoint |
684 | 2 | .daemon_main( |
685 | 2 | windows_api, |
686 | 2 | args.hosts.to_owned(), |
687 | 2 | args.username.clone(), |
688 | 2 | args.port, |
689 | 2 | &config.daemon, |
690 | 2 | &config.clusters, |
691 | 2 | args.debug, |
692 | 2 | ) |
693 | 2 | .await; |
694 | | } |
695 | | None => { |
696 | | // If no hosts provided, show help and handle GUI vs console launch |
697 | 6 | if args.hosts.is_empty() { |
698 | 5 | let _ = args_command.print_help(); |
699 | | |
700 | | // If launched from GUI, allow user to input arguments interactively |
701 | 5 | if launched_from_gui { |
702 | 2 | run_interactive_mode( |
703 | 2 | windows_api, |
704 | 2 | args_command, |
705 | 2 | entrypoint, |
706 | 2 | config_manager, |
707 | 2 | &config, |
708 | 2 | &config_path, |
709 | 2 | output, |
710 | 2 | input, |
711 | 2 | ) |
712 | 2 | .await; |
713 | 3 | } |
714 | 5 | return; |
715 | 1 | } |
716 | | |
717 | 1 | entrypoint.main(windows_api, config_manager, &config_path, &config, args); |
718 | | } |
719 | | } |
720 | 10 | } |
721 | | |
722 | | #[cfg(test)] |
723 | | #[path = "./tests/test_cli.rs"] |
724 | | mod test_cli; |